Report code churn across the codebase - #196
Conversation
Added + deleted lines measures how big a PR is, which the diff already says. The question worth asking in review is different: is this change writing new code, or rewriting code we wrote recently? A pattern of the second is how an encoding problem shows up -- the same area fixed again and again, each fix reasonable on its own. The metric is rework: of the lines this PR deletes, how many were themselves written inside the window (default 30 days). Ages come from blaming the merge base, and are measured against the merge base's own commit time rather than wall-clock now, so re-running CI later reports the same number. Reported alongside it: the median age of deleted lines, which is the sharper signal of the two -- near zero means the change is undoing work from the same few days -- and the files in the diff that other commits in the window also deleted from. Hotspots count commits that DELETED from a file, not commits that touched it. By the touch definition test/unit/test_e2e.cpp led every commit measured (38-46) for the uninteresting reason that every PR appends a scenario to it; by the delete definition it drops to 7 and the files actually being rewritten come first. The rework percentage is withheld below 20 deleted lines. A two-line diff that happens to touch recent code is not "100% rework", and a ratio over a handful of lines says nothing. Calibration against this repo's own history, which is what the metric is for: Make stale removal file-granular (fixing that day's work) 85.7% 0.0d Split command identity into key and signature 67.6% 11.2d Delete StringId's relational operators (21 old sites) 37.5% 121.7d No baseline artifact: comparing a rework ratio against the single previous commit on main is noise, and the median age is self-explanatory without calibration. A trailing-window comparison would be the way to add one. Runs in the existing pr-metrics-comment job, which already checks out and only runs on pull requests, so churn needs no artifact round-trip -- only fetch-depth 0, since blame cannot see history in a shallow clone. Costs 0.6s on the largest commit in recent history. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA
Every git call in cmd_churn degrades to empty output rather than raising, which is right for the one case where emptiness is the answer (blaming a file the PR adds), but it means a shallow clone or an unreachable base would be reported as zero churn instead of as a failure. Silence and "nothing was reworked" looked identical. Both now refuse: a shallow repository names the fetch-depth it needs, and a base with no merge base against head says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA
PR metricsPerformance (gcc example, Linux)
Deterministic signals: instructions (cachegrind-simulated instruction reads — exact across runs, no PMU needed), page faults, peak RSS, and the cachegrind D1/LL miss rates. CPU time is user+sys from time(1). Internal statistics (gcc example, up-to-date dry run)
Counters from Binary size (Linux)
Code churn (whole codebase, last 30d)
Of the lines written across the codebase in the last 30 days, how many are already gone — work that was written and then discarded or rewritten inside the same window. This is the state of the tree including this PR, not a measure of the PR itself. Only code we write is counted: tests, examples, vendored and generated files, CI plumbing and prose are excluded. 3562 lines were deleted in the window in total, most of them older than it. Where the churn is
Test coverage (lines)
103 files · 15163/17494 lines covered Deltas vs main@fc14f89de. Updated for 6d0e89b |
Tests are rewritten whenever the code they cover is, so counting them measured the
same production change twice and diluted the signal with churn that is working as
intended. test/ is 408 of the repo's 694 tracked files, which is why it dominated
every reading. Excluding it moves the numbers without changing the ranking:
before after
Make stale removal file-granular 112 rm 85.7% 109 rm 85.3%
Split identity into key and signature 142 rm 67.6% 104 rm 69.2%
Delete StringId's relational operators 24 rm 37.5% 24 rm 37.5%
The last row is unchanged because that PR removed nothing from tests at all.
The hotspot list goes to ten, which no longer fits a sentence, so it renders as a
table inside a <details> block rather than pushing the summary out of view. Ties
now break by path so the list is stable between runs rather than depending on
which file the diff happened to list first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA
A purely additive PR removes nothing, so there are no line ages to take a median of -- and the table printed "0.0 d". That is the strongest churn reading the metric can produce, shown for the situation with the least churn possible. The same cell said the same thing whether a change was undoing that morning's work or adding a new file. An empty measurement now reports n/a in the TSV and renders as an em dash, which keeps it distinct from the measured zero that matters: fixing code written the same day still reads 0.0 d, and that is the reading the metric exists to surface. The related failure is worse and is now an error rather than a number. If a diff removes lines but none of them can be dated -- blame failing on a rename, a base that is present but unreadable -- the ages list is empty for a reason that has nothing to do with churn, and the old code reported 0 reworked lines out of a real removal count. It now exits nonzero naming how many lines it could not date. Counts were correct throughout and are unchanged: this PR is 199 added, 0 removed against main, which git agrees with, because its later commits only edited lines the PR itself introduced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA
…gate hotspots The table and the file list answer different questions over different ranges, and nothing said so. The counts are the PR's own diff, merge-base..head. The file list is the same files' history reachable from the merge base -- so it is what happened BEFORE the PR, deliberately excluding the PR's own commits, and it is context for the diff rather than part of it. Both now say which they are. The hotspot rule counts only commits that deleted from a file. That was introduced because test/unit/test_e2e.cpp led every reading purely by being appended to, and tests are now excluded outright, so the rule was worth re-justifying rather than keeping out of habit. It still discriminates: over a recent window, 59 of 102 production files count differently under touched-vs-deleted-from, and four are append-only with more than one touch -- include/pup/core/string_id.hpp, include/pup/cli/context.hpp, include/pup/core/region.hpp and src/index/writer.cpp. string_id.hpp is the clearest: two commits added deleted operators to it and neither removed anything, so it grows without being rewritten. Under the touched rule it would report as a hotspot. The measurement is recorded at the site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA
A per-PR rework number answers "is this change rewriting recent work", which is
a property of the change. The question worth a standing metric is a property of
the tree: how much of what we wrote recently is already gone.
Churn is now, over a window ending at the analyzed commit: of the lines written
in that window, how many are no longer present. One `git log --numstat --since`
pass gives what was written per file; one blame pass over those files counts what
survived; the difference is work written and then discarded or rewritten inside
the same window. Dated against the analyzed commit rather than wall-clock now, so
a re-run agrees.
On a pull request it analyzes the PR head, so the reading is the state of the tree
if this lands -- not a score for the PR.
window written churned rate
7d 3230 471 14.6%
14d 9335 1151 12.3%
30d 9916 1221 12.3%
90d 10399 1296 12.5%
Stable across windows, and higher over the shortest one, which is what you would
expect if fresh code is what gets revised.
Exclusions are now tests, third_party/, examples/ and the generated bootstrap
scripts. The bootstrap scripts earned their exclusion by ranking 2nd and 3rd on
the first run: `make bootstrap` regenerates them from the Tupfiles, so their churn
is a mechanical echo, at roughly ten times the volume, of a change already counted
at its source.
Top of the list is src/cli/cmd_build.cpp at 249 lines written then discarded,
which is where the identity and join work has been concentrated -- the metric
finds the area we already know has been rewritten repeatedly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA
.github/ is how the project is built and checked, not the thing being built, and
its churn was inflating the rate by about three points: 13.1% with it, 9.8%
without. Five of the top ten entries were CI scripts, three of them files that no
longer exist because they were consolidated into this one -- real work, rewritten,
but not where a build system's defects come from.
Excluding it left TESTING.md ranked tenth, which is the same category error one
step further out: prose is not code. Markdown accounts for 483 of the lines
written in the window. Excluded by suffix, since docs live both under docs/ and at
the root, along with docs/ itself.
The top ten is now entirely src/, led by cmd_build.cpp at 249 lines written then
discarded -- the identity and join work -- followed by layout.cpp, new_delete.cpp
and context.cpp.
window files written churned rate
7d 49 2525 398 15.8%
14d 115 7335 756 10.3%
30d 119 7502 770 10.3%
90d 119 7886 844 10.7%
Still stable across windows and still highest over the shortest, so the narrower
denominator has not made the number fragile.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA
A per-PR rework number answers "is this change rewriting recent work" — a property of the change. The question worth a standing metric is a property of the tree: how much of what we wrote recently is already gone?
The metric
Over a window ending at the analyzed commit: of the lines written in that window, how many are no longer present.
One
git log --numstat --sincepass gives what was written per file; one blame pass over those files counts what survived; the difference is work written and then discarded or rewritten inside the same window. Dated against the analyzed commit rather than wall-clock now, so a re-run agrees.On a pull request it analyzes the PR head, so the reading is the state of the tree if this lands — not a score for the PR.
Where the churn is
src/cli/cmd_build.cpp.github/scripts/compose_metrics.py.github/scripts/bench_metrics.shsrc/core/layout.cppsrc/core/new_delete.cppTop of the list is
src/cli/cmd_build.cppat 249 lines written then discarded — where the identity and join work has been concentrated. The metric finds the area we already knew had been rewritten repeatedly, which is the check that it measures something real.Stability
Stable across windows, and highest over the shortest one — what you would expect if fresh code is what gets revised. The number is not an artifact of the window choice.
Exclusions, and how one of them was found
Tests,
third_party/,examples/, and the generated bootstrap scripts.test/is 408 of 694 tracked files.third_party/is vendored — its history is imports, not our work.examples/is build recipes for other people's source.bootstrap-linux.sh/bootstrap-macos.shearned their exclusion by ranking 2nd and 3rd on the first run.make bootstrapregenerates them from the Tupfiles, so their churn is a mechanical echo — at roughly ten times the volume — of a change already counted at its source.That last one is the argument for looking at the output before trusting it: it was 242 and 241 lines of pure noise sitting directly under the top result.
Failure behaviour
Every git call degrades to empty output rather than raising, so a missing history would report zero churn instead of failing — silence and "nothing churned" look identical. A shallow clone, an unknown revision, and a window containing no production changes each refuse with a message. Verified against a real
--depth 1clone.Cost
2.1s, inside the existing
pr-metrics-commentjob — which already checks out and only runs on pull requests, so no new job and no artifact round-trip. It needsfetch-depth: 0, since neither blame nor a 30-day log works in a shallow clone.🤖 Generated with Claude Code
https://claude.ai/code/session_01TLob4Ef3qyMd3Dnp9q3XmA